iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統系列 第 16 篇

Day 16:主角降臨!2D 像素精靈 (THREE.Sprite)、手持動態火把與網格碰撞移動系統

  • 分享至 

  • xImage
  •  

在 Day 15 中,我們完成了古堡地宮的場景建設、動態壁燈光影以及 2.5D 相機鎖定迴圈。然而,這座精緻的世界目前還缺乏一位探索者。

在 HD-2D 的設計美學中,最核心的視覺衝擊來自於「3D 精緻世界」與「2D 復古像素紙片人」的結合。今天我們將正式實裝玩家角色,利用 THREE.Sprite 解決 2D 平面在 3D 視角中躺平的痛點,為主角掛載一盞照亮周遭的隨身火把,並結合地圖二維陣列實作精準的網格碰撞與移動控制。

【核心開發目標】

  • 實裝 Billboard 精靈圖與精準橫向 UV 裁切:使用 THREE.Sprite 對齊實際素材規格(idle 5 幀、running 6 幀)。
  • 修正像素錨點與角色尺度:將精靈錨點設於腳底防止陷地,並將身形調整為 2.8 倍以匹配高大石牆。
  • 隨身手持火把與雙向座標映射:掛載隨身照明,並實作網格 (r, c) 與世界座標 (X, Y, Z) 相互轉換矩陣。 四點邊界碰撞與滑牆移動演算法(Wall Sliding):實作流暢的軸向分離滑動,阻擋高牆與碎石。
  • 水平鏡像翻轉、動畫計時切換與相機平滑跟隨:透過 scale.x 翻轉朝向,以時間差驅動動畫,並以 Lerp 讓相機跟隨閉環。

【Step 1:引入精靈圖素材、Billboard 機制與橫向 UV 裁切】

如果將 2D 圖片貼在標準平面網格(PlaneGeometry)上,當相機以 28 度低仰角俯視時,角色看起來會像一張躺在地上的貼紙;手動計算旋轉矩陣讓平面永遠面對相機又極耗效能。Three.js 的 THREE.Sprite 在底層天生具備 Billboard 特性,無論視角如何移動,都會自動維持與鏡頭視線垂直。 在真實專案中,角色動畫通常是由橫向動畫條(Strip)構成,而非對稱的方形宮格。我們的實際素材為待機 5 幀(IDLE_COLS = 5)與跑動 6 幀(RUN_COLS = 6)。我們透過設定 repeat 與 offset 來實現精準的單幀 UV 裁切:

//將角色的精靈圖與地圖的素材分開存放以方便維護
import idleTextureUrl from '../2D Pixel Art Character Template - Platformer & Metroidvania/idle/idle.png';[cite: 4]
import runTextureUrl from '../2D Pixel Art Character Template - Platformer & Metroidvania/running/running.png';[cite: 4]

// 載入貼圖並強制保持 NearestFilter 像素銳利
const idleTex = loadPixelTexture(idleTextureUrl);[cite: 4]
const runTex = loadPixelTexture(runTextureUrl);[cite: 4]

// 依據素材規格定義橫向欄數 (單列動畫條)
const IDLE_COLS = 5;[cite: 4]
const RUN_COLS = 6;[cite: 4]

// 設定橫向裁切寬度 (1 / COLS),縱向高度保持 1
idleTex.repeat.set(1 / IDLE_COLS, 1);[cite: 4]
idleTex.offset.set(0, 0);[cite: 4]

runTex.repeat.set(1 / RUN_COLS, 1);[cite: 4]
runTex.offset.set(0, 0);[cite: 4]

// 建立帶有透明度測試的 Sprite 材質
const playerMat = new THREE.SpriteMaterial({
  map: idleTex,
  transparent: true,
  alphaTest: 0.1 // 剔除邊緣半透明雜質,保持邊界刀鋒銳利
});
const playerSprite = new THREE.Sprite(playerMat);[cite: 4]

【Step 2:解決「下半身陷地」與身形比例調整】
THREE.Sprite 預設的幾何中心錨點為 (0.5, 0.5)(正中心)。如果將 Sprite 的座標直接設在地表高度,角色的下半身會硬生生插入地面石磚內部。 此外,如果角色比例僅設定為 1x1,在 2~3 格高的城牆對比下會顯得過於矮小。我們必須校正錨點至腳底,並放大身形比例:

// 關鍵修正:將錨點重設為腳底底部正中心 (0.5, 0.0)
playerSprite.center.set(0.5, 0.0);

// 放大角色比例至 2.8 倍,與 2~3 層高的古堡石牆呈現等高挺拔的黃金比例
const playerBaseScale = 2.8;
playerSprite.scale.set(playerBaseScale, playerBaseScale, 1);

// 建立主角層級群組
const playerGroup = new THREE.Group();
playerGroup.add(playerSprite);

【Step 3:手持隨身火把與雙向座標轉換矩陣】
為了強化探險氛圍,我們為主角配置一盞隨身暖光點光源,並建立「二維網格行列(Row, Col)」與「3D 世界座標(X, Y, Z)」的雙向換算邏輯:

// 配合身形調高隨身火把至胸口高度 (y=1.3),並設定 7.0 照亮半徑
const playerLight = new THREE.PointLight('#ffb066', 2.0, 7.0, 1.3);
playerLight.position.set(0, 1.3, 0.2);
playerGroup.add(playerLight);
scene.add(playerGroup);

// 1. 網格座標 (r, c) 轉換為 3D 世界座標 (X, Y, Z)
function gridToWorld(r, c) {
  return {
    x: c - (mapSize / 2) + 0.5,
    y: 1.0, // 地表頂面基準高度
    z: r - (mapSize / 2) + 0.5
  };
}

// 2. 3D 世界座標 (X, Z) 反向映射為網格座標 (r, c)
function worldToGrid(x, z) {
  return {
    row: Math.round(z + (mapSize / 2) - 0.5),
    col: Math.round(x + (mapSize / 2) - 0.5)
  };
}

// 設定主角安全出生點:中央大道平坦石磚 (row 25, col 24)
const spawnGrid = { r: 25, c: 24 };
const spawnPos = gridToWorld(spawnGrid.r, spawnGrid.c);
playerGroup.position.set(spawnPos.x, spawnPos.y, spawnPos.z);

// 初始化相機目標對齊主角胸口 (y = 1.6)
cameraTarget.set(spawnPos.x, 1.6, spawnPos.z);

【Step 4:四點碰撞箱與滑牆移動演算法(Wall Sliding)】
單純檢測單一中心點的碰撞容易產生邊緣穿模。我們為角色定義一個半徑為 0.32 的四角取樣點邊界框(Bounding Box)。 更重要的是,若玩家朝斜前方(例如同時按 W 與 D)撞上牆壁,傳統碰撞檢測會直接把位移歸零,導致角色強烈卡頓。我們實作了軸向分離滑牆演算法:當斜向受阻時,分別測試單獨走 X 軸或單獨走 Z 軸,讓角色能順暢地貼著牆壁滑行:

// WASD 與方向鍵狀態監聽
const keys = {
  w: false, a: false, s: false, d: false,
  ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false
};

window.addEventListener('keydown', (e) => {
  const k = e.key.toLowerCase();
  if (k === 'w' || e.key === 'ArrowUp') keys.w = true;
  if (k === 's' || e.key === 'ArrowDown') keys.s = true;
  if (k === 'a' || e.key === 'ArrowLeft') keys.a = true;
  if (k === 'd' || e.key === 'ArrowRight') keys.d = true;
});

window.addEventListener('keyup', (e) => {
  const k = e.key.toLowerCase();
  if (k === 'w' || e.key === 'ArrowUp') keys.w = false;
  if (k === 's' || e.key === 'ArrowDown') keys.s = false;
  if (k === 'a' || e.key === 'ArrowLeft') keys.a = false;
  if (k === 'd' || e.key === 'ArrowRight') keys.d = false;
});

// 碰撞半徑設定為 0.32,兼顧身形並保留穿過 1 格窄道的餘裕
const playerCollisionRadius = 0.32;

function checkCollision(x, z) {
  const r = playerCollisionRadius;
  // 建立四角取樣點
  const testPoints = [
    { x: x - r, z: z - r },
    { x: x + r, z: z - r },
    { x: x - r, z: z + r },
    { x: x + r, z: z + r }
  ];

  for (const pt of testPoints) {
    const grid = worldToGrid(pt.x, pt.z);
    // 檢查邊界越界
    if (grid.row < 0 || grid.row >= gridRows || grid.col < 0 || grid.col >= gridCols) {
      return true;
    }
    const tileType = mapGrid[grid.row][grid.col];
    // 阻擋代碼 5 (牆壁) 與代碼 2 (碎石)
    if (tileType === 5 || tileType === 2) {
      return true;
    }
  }
  return false;
}

【Step 5:水平鏡像翻轉、動畫計時切換與相機平滑跟隨】
THREE.Sprite 由於 GPU Billboard 鎖定機制的緣故,無法使用傳統的 rotation.y 來完成角色轉身;正確的做法是對 X 軸縮放值進行正負切換(scale.x = -playerScale)。 我們封裝了 updatePlayer(dt) 函式,以真實時間差(dt)驅動位移與動畫計時,並在主迴圈中以 MathUtils.lerp 實現相機的動態追焦:


const playerSpeed = 4.5;
let currentAnim = 'idle';
let currentFrame = 0;
let animTimer = 0;
let facingRight = true;

function updatePlayer(dt) {
  let moveX = 0;
  let moveZ = 0;

  if (keys.w) moveZ -= 1;
  if (keys.s) moveZ += 1;
  if (keys.a) moveX -= 1;
  if (keys.d) moveX += 1;

  const isMoving = (moveX !== 0 || moveZ !== 0);

  if (isMoving) {
    // 斜向向量正規化,防止對角線移動超速
    const len = Math.hypot(moveX, moveZ);
    moveX /= len;
    moveZ /= len;

    // 水平朝向判斷
    if (moveX < -0.01) facingRight = false;
    else if (moveX > 0.01) facingRight = true;

    const dx = moveX * playerSpeed * dt;
    const dz = moveZ * playerSpeed * dt;
    const currX = playerGroup.position.x;
    const currZ = playerGroup.position.z;

    // 滑牆處理 (Wall Sliding)
    if (!checkCollision(currX + dx, currZ + dz)) {
      playerGroup.position.x += dx;
      playerGroup.position.z += dz;
    } else {
      // 斜向受阻時,嘗試單軸滑動
      if (!checkCollision(currX + dx, currZ)) playerGroup.position.x += dx;
      if (!checkCollision(currX, currZ + dz)) playerGroup.position.z += dz;
    }

    // 動畫切換:跑步
    if (currentAnim !== 'run') {
      currentAnim = 'run';
      currentFrame = 0;
      animTimer = 0;
      playerMat.map = runTex;
      playerMat.needsUpdate = true;
    }
  } else {
    // 動畫切換:待機
    if (currentAnim !== 'idle') {
      currentAnim = 'idle';
      currentFrame = 0;
      animTimer = 0;
      playerMat.map = idleTex;
      playerMat.needsUpdate = true;
    }
  }

  // 鏡像翻轉:透過 scale.x 正負值實作水平轉向
  playerSprite.scale.x = facingRight ? playerBaseScale : -playerBaseScale;

  // 動畫幀輪播更新
  const frameDuration = currentAnim === 'run' ? 0.09 : 0.16;
  const totalFrames = currentAnim === 'run' ? RUN_COLS : IDLE_COLS;

  animTimer += dt;
  if (animTimer >= frameDuration) {
    animTimer = 0;
    currentFrame = (currentFrame + 1) % totalFrames;
    if (playerMat.map) {
      playerMat.map.offset.x = currentFrame / totalFrames;
    }
  }
}

最後,將渲染迴圈中的相機更新改為帶有阻尼感的線性插值跟隨:


function animate() {
  requestAnimationFrame(animate);

  // 取得真實幀間時間差 (防止切換視窗產生過大時間差)
  const dt = Math.min(clock.getDelta(), 0.1);
  const elapsedTime = clock.getElapsedTime();

  // 壁燈火光微動
  animatedLights.forEach(item => {
    const flicker = Math.sin(elapsedTime * item.speed + item.phase) * 0.35 +
                    Math.cos(elapsedTime * item.speed * 2.1 + item.phase) * 0.2;
      
    item.light.intensity = item.baseIntensity + flicker;

  // 每幀更新主角物理、滑牆碰撞與動畫
  updatePlayer(dt);

  // 相機以 0.08 線性插值平滑跟隨主角,鎖定胸口核心高度 y = 1.6
  cameraTarget.x = THREE.MathUtils.lerp(cameraTarget.x, playerGroup.position.x, 0.08);
  cameraTarget.z = THREE.MathUtils.lerp(cameraTarget.z, playerGroup.position.z, 0.08);
  cameraTarget.y = 1.6;

  camera.position.copy(cameraTarget).add(cameraOffset);
  camera.lookAt(cameraTarget);

  renderer.render(scene, camera);
}

animate();

至此,你的 2D 像素主角已經在 3D 古堡地宮中奔跑起來。
雖然目前主角還沒有拿火把,因為素材是別人開放免費下載的,大家可以去這個網站下載自己喜歡的素材,後續會想辦法把火把的素材補上的。
https://n3cloud.itch.io/2d-pixel-art-character-template-platformer-metroidvania
image

【本日結語】
在 WASD 的操控下,角色具備流暢的待機與跑動動作切換,遇到石牆或碎石時能自然滑動而不卡死,且鏡頭以 2.5D 平視低仰角平滑伴隨主角移動,正式達成了 HD-2D 的完整動態體驗,但是現在角色只能朝著右邊奔跑,沒有其他方向的動作,Day17將繼續完善這些畫面上的小細節,後續的日子裡會增加更多光影和畫面的細節讓他變得更有HD-2D的感覺。


上一篇
Day 15:萬事俱備!動態鏡頭鎖定系統與 HD-2D 地城完工總結
下一篇
Day 17:修復方向盲區!Three.js Sprite 著色器翻轉真相、四向朝向狀態機、火把動態換手與指數平滑運鏡
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言